Skip to content

feat: explicit proxy configuration for the CLI - #698

Draft
NickJosevski wants to merge 11 commits into
mainfrom
nj/issue-49
Draft

NickJosevski wants to merge 11 commits into
mainfrom
nj/issue-49

Conversation

@NickJosevski

Copy link
Copy Markdown
Contributor

Refs #49

Baseline: standard proxy env vars already work

Before adding anything, I checked whether the CLI loses Go's built-in proxy support. It does not, for normal API traffic.

  • pkg/apiclient/client_factory.go:129 builds the http client with NewSpinnerRoundTripper(ask)
  • pkg/apiclient/spinner_round_tripper.go:19 sets Next: http.DefaultTransport
  • http.DefaultTransport.Proxy is http.ProxyFromEnvironment

So HTTP_PROXY, HTTPS_PROXY and NO_PROXY have always been honoured for every Octopus API call. If that is all a customer needs, no CLI change was ever required. That reframes the issue: this is not "add proxy support", it is "add explicit configuration and close two gaps".

The two real gaps

  1. octopus login --ignore-ssl-errors lost the proxy. pkg/cmd/login/login.go:131 built a bare &http.Transport{}, whose Proxy field is nil — proxy support silently gone for exactly the command a new user runs first.
  2. The same line panicked. httpClient.Transport.(*http.Transport) is an unchecked type assertion. When the CLI is already configured, f.GetHttpClient() returns the client whose transport is a *SpinnerRoundTripper, so octopus login --ignore-ssl-errors crashed with interface conversion. Reproducible before this change; covered by a test now.

What changed

New pkg/apiclient/proxy.go:

  • ProxySettings + ProxySettingsFromConfig() — reads the config/env
  • ProxyFunc() — resolution built on golang.org/x/net/http/httpproxy (the same package net/http uses), so NO_PROXY semantics match the standard library exactly
  • NewHttpTransport(settings, insecureSkipVerify)clones http.DefaultTransport instead of mutating it, keeping every standard default (including proxy) and no longer poisoning the process-wide transport
  • RedactProxyUrl() — for display

Wiring: OCTOPUS_PROXY env var + ProxyUrl config key (both were already stubbed out in commented-out code across constants.go, config.go, config get, config set — this uncomments and completes them), plus config list support with redaction, and a fixed login path.

Precedence

source notes
1 OCTOPUS_PROXY via viper's env binding
2 ProxyUrl in cli_config.json octopus config set ProxyUrl ...
3 HTTPS_PROXY / HTTP_PROXY standard behaviour, unchanged

An explicit OCTOPUS_PROXY/ProxyUrl applies to both http and https requests (it replaces both env vars). NO_PROXY is honoured in every case, including over an explicit setting. Loopback targets are never proxied (standard Go behaviour, and what you want against a local Octopus).

Credentials

user:pass@host in the url works. Separately, OCTOPUS_PROXY_USERNAME / OCTOPUS_PROXY_PASSWORD apply to whichever proxy url was resolved — including one from HTTPS_PROXY — and lose to credentials already in the url.

Deliberately environment-only: they are read with os.Getenv, not bound into viper, so they cannot be persisted to cli_config.json in plain text. config list redacts any password in ProxyUrl via url.Redacted() (http://octo:xxxxx@proxy:3128), matching how ApiKey/AccessToken are already masked at pkg/cmd/config/list/list.go:38-44. The password is never logged or echoed.

Out of scope, with reasons

  • NTLM — not supported. Go has no stdlib NTLM/Negotiate; http.Transport only does Basic proxy auth. It would mean a third-party dependency (e.g. Azure/go-ntlmssp) doing a 3-leg handshake with connection affinity, plus SSPI for transparent single-sign-on on Windows. Real work, a supply-chain decision, and no test story without a Windows domain. Recommend a separate issue, driven by an actual customer request.
  • SOCKS — free, and included. net/http's transport dials socks5:// and socks5h:// proxy urls itself (socks_bundle.go, transport.go:1835). No extra dependency, no extra code. OCTOPUS_PROXY=socks5://host:1080 works and is covered by a test.

Test evidence

go build ./... clean. go test ./pkg/... all green (go vet reports 4 pre-existing "unreachable code" hits in unrelated files).

  • pkg/apiclient/proxy_test.go — 14-case table over proxy resolution: no config, HTTP_PROXY/HTTPS_PROXY per scheme, explicit config overriding env, scheme-less host:port, socks5, NO_PROXY against both explicit and env proxies, loopback, and the three credential paths. Plus an invalid-url error case, ProxySettingsFromConfig, and a RedactProxyUrl table asserting the password never survives.
  • End-to-end through a real proxy, no network or Docker: TestNewHttpTransport_SendsRequestsThroughTheProxy stands up an httptest server as the proxy and asserts the absolute-form request URI and the Proxy-Authorization: Basic header arrive at it.
  • TestNewHttpTransport_LeavesTheDefaultTransportAlone guards the shared-transport mutation regression.
  • pkg/cmd/login/login_test.goTestConfigureHttpClient covers all three branches, including the one that used to panic.
  • pkg/config/config_test.go — proves OCTOPUS_PROXY is actually bound to ProxyUrl.

Every test is hermetic; clearProxyEnvironment stops the CI machine's own proxy settings leaking in.

Open questions / options

1. Is an explicit setting wanted at all, or is env-only enough?
Since HTTPS_PROXY already worked, OCTOPUS_PROXY buys one thing: pointing the CLI at a proxy without redirecting every other tool on the box. That is genuinely useful in CI, but it is new surface to document and support. Recommend keeping it — it is the thing the issue actually asks for, and it is cheap.

2. No --proxy flag, and there is a concrete reason.
--proxy is already taken: pkg/machinescommon/proxy.go:15 registers it on target ssh create, target listening-tentacle create and the worker equivalents, where it names an Octopus proxy resource. A root persistent --proxy would be shadowed by the local flag on exactly those commands — confusing for two different meanings of the word. Second obstacle: the client factory is built in cmd/octopus/main.go:53, before cobra parses flags (the same ordering the spinner round-tripper comments call out), so a flag needs either lazy per-request resolution or a reordering. Options: (a) ship env/config only — my recommendation for this PR; (b) add --proxy-url with lazy resolution, ~10 lines on top of this; (c) reorder factory construction. Happy to do (b) if the team wants a flag.

3. Credential env var names. The issue says PROXY_USERNAME/PROXY_PASSWORD; I used OCTOPUS_PROXY_USERNAME/OCTOPUS_PROXY_PASSWORD to match every other OCTOPUS_* var. Unprefixed names risk colliding with other tooling. Easy to also accept the unprefixed names as a fallback if there is a compatibility reason.

4. Should ProxyUrl accept credentials at all? It can today, and config list redacts it — but the password still sits in cli_config.json in plain text, same as ApiKey does. Alternative: reject a url containing a password on config set and force the env vars. Slightly more secure, slightly more annoying. Want that?

5. Test matrix — what squid in Docker would add. The unit tests cover resolution and one real proxy hop, but not: CONNECT tunnelling for https targets (the httptest proxy sees an absolute URI, not a CONNECT), a 407 challenge/response round, proxies that mangle or buffer chunked responses, and TLS-terminating proxies with a corporate root CA. A squid container in CI would cover the first three; the fourth needs a generated CA and is where real customer pain usually lives. Suggest one squid-based integration test (anonymous + basic-auth) in the existing integration suite, kept out of the unit run. Worth noting the integration suite has its own CI problems today, so I did not add anything that depends on it.

6. Unrelated but worth flagging: pkg/apiclient/client_factory.go:124 (before this change) set InsecureSkipVerify: true unconditionally on the global http.DefaultTransport — the CLI never verifies Octopus's TLS certificate, and login --ignore-ssl-errors is effectively always on. I preserved the behaviour rather than change it in a proxy PR (it is now scoped to the CLI's own transport instead of the whole process), but it looks like a security bug and deserves its own issue.

🤖 Generated with Claude Code

Comment thread pkg/apiclient/client_factory.go Outdated
}

http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
transport, err := NewHttpTransport(ProxySettingsFromConfig(), true)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TLS verification is unconditionally disabled for all API traffic. insecureSkipVerify is hardcoded true at this call site, so the CLI never verifies the Octopus server certificate and --ignore-ssl-errors is effectively always on (the PR description acknowledges this in open question 6). Now that the insecure flag is an explicit parameter, this is the natural moment to plumb the real setting through (default false) or at minimum open the follow-up issue before merging — a MITM on any network path to the server can silently capture the API key sent on every request.

@NickJosevski NickJosevski Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actioned in 884abe2. Confirmed the finding first: on this branch NewClientFactoryFromConfig passed a literal true, so every *http.Transport the factory built carried InsecureSkipVerify: true and no command verified the server certificate.

Before: verification off for all API traffic, unconditionally, with no way to turn it on. After: verification on, and turning it off is an explicit opt-in via one of

  • OCTOPUS_IGNORE_SSL_ERRORS=true
  • octopus config set IgnoreSslErrors true
  • octopus login --ignore-ssl-errors (one login only)

Plumbing, rather than just deleting the true: new IgnoreSslErrors config key defaulted to false in setDefaults, bound to OCTOPUS_IGNORE_SSL_ERRORS in bindEnvironment, read at the call site through apiclient.IgnoreSslErrorsFromConfig(). config set rejects a non-boolean value for it, because viper reads yes back as false and a user who thinks they turned verification off deserves an error rather than a setting that silently does nothing. The key is also listed by config list -f json and offered by the config get/config set pickers.

Tests: TestNewClientFactoryFromConfig_TlsVerification (table: default, explicit false, true, "true") asserts on the transport the factory actually hands out; TestSetup_DefaultsToVerifyingTheServerCertificate and TestSetup_BindsTheIgnoreSslErrorsEnvironmentVariable cover the key itself.

Verified end to end with a binary built from 884abe2 against https://self-signed.badssl.com:

default:                          tls: failed to verify certificate: x509: certificate signed by unknown authority
OCTOPUS_IGNORE_SSL_ERRORS=true:   invalid character '<' looking for beginning of value   (past TLS, failing on the HTML body)

This is a breaking change and it is deliberate — the whole point is that the old behaviour was silently insecure. Anyone on a self-signed or internal-CA certificate who is not currently opted out will start getting x509: certificate signed by unknown authority after upgrading. The README section I added says exactly that, recommends fixing the trust store first, and flags the opt-out as trust-the-network-path only. Worth calling out in the release notes; your call whether that is enough or whether this needs to land on its own release boundary.

configData.Host = configFile.GetString(key)
case strings.ToLower(constants.ConfigNoPrompt):
configData.NoPrompt = configFile.GetString(key)
case strings.ToLower(constants.ConfigProxyUrl):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While extending this switch: config list -f json currently hard-errors for anyone who has run octopus login, because both loginWithApiKey and loginWithOpenIdConnect always write AccessToken to the config file (even as ""), and accesstoken has no case here (nor a ConfigData field) so it falls into default: return fmt.Errorf(...). Verified empirically on this branch: a config file containing accesstoken makes listRun return the key 'accesstoken' is not a supported config option and print nothing. ShowOctopus hits the same default. Pre-existing, but this PR touches the switch and adds a masked accesstoken entry via configFile.Set(constants.ConfigAccessToken, "***") above, so it is worth fixing here or in a fast follow.

@NickJosevski NickJosevski Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actioned in 82f320d, extended in 884abe2.

The finding holds as written. On origin/main the ConfigData struct has no AccessToken and no ShowOctopus field and the switch has neither case, so both keys fall into default:accesstoken is written by every octopus login (as "" for the OIDC/api-key path that does not use it), which is what made it hit everyone who had logged in.

82f320d adds the accesstoken and showoctopus cases and fields. 884abe2 adds ignoresslerrors alongside them, since that key is new in this PR and would otherwise have reintroduced exactly the same failure the moment someone set it.

Verified with a binary built from 884abe2 against a config file containing accesstoken, apikey, ignoresslerrors, proxyurl and url:

{
  "accesstoken": "***",
  "apikey": "***",
  "editor": "",
  "host": "https://octopus.example.com",
  "ignoresslerrors": "true",
  "noprompt": "",
  "outputformat": "",
  "proxyurl": "http://octo:[email protected]:3128",
  "showoctopus": "",
  "space": ""
}

exit 0, where the same file on main returns the key 'accesstoken' is not a supported config option.

Residual, unchanged: the default: return fmt.Errorf(...) is still there, so the next config key anyone adds without touching this switch breaks -f json the same way. Enumerating ConfigData from the constants, or skipping unknown keys instead of erroring, would make that structural — out of scope here, and it needs a call on whether an unknown key in the file should be an error at all.

Comment thread pkg/cmd/login/login.go Outdated
// a configured client already carries a proxy-aware transport, so only the ssl
// override needs applying. Any other transport belongs to a caller (tests mock one
// in here) and is left alone.
if spinnerRoundTripper, ok := httpClient.Transport.(*apiclient.SpinnerRoundTripper); ok && ignoreSslErrors {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Behavior narrowing vs the old code: the removed code applied InsecureSkipVerify to any non-nil client, including one with a nil Transport (if httpClient.Transport == nil { httpClient.Transport = &http.Transport{} }). The new code only handles *apiclient.SpinnerRoundTripper; for a non-nil client with a nil or any other transport, --ignore-ssl-errors is now silently dropped (the new test even enshrines this). Unreachable via NewClientFactoryFromConfig/the stub today, but any factory implementation that returns a plain &http.Client{} gets a flag that does nothing, with no warning. Consider handling httpClient.Transport == nil explicitly (build the proxy-aware transport, as the nil-client branch does) so the previously-supported case keeps working.

@NickJosevski NickJosevski Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actioned in 4492488.

The narrowing was real: the removed code did if httpClient.Transport == nil { httpClient.Transport = &http.Transport{} } before setting TLSClientConfig, and the first version of ConfigureHttpClient only matched *apiclient.SpinnerRoundTripper, so a non-nil client with a nil transport silently lost --ignore-ssl-errors (and got http.DefaultTransport, which knows nothing about the CLI's proxy settings either — so it lost the proxy too, which is arguably the worse half).

ConfigureHttpClient now has an explicit httpClient.Transport == nil branch that builds the proxy-aware transport, the same way the nil-client branch does, and assigns it into the caller's client. The test that enshrined the old behaviour is replaced by TestConfigureHttpClient/"gives a client with no transport a proxy aware one", which asserts both halves: InsecureSkipVerify is set, and transport.Proxy resolves to the configured proxy.

Transport ordering after the change: nil client → build; nil transport → build and assign; *SpinnerRoundTripper → rebuild Next; anything else → left alone (covered by TestConfigureHttpClient/"leaves a transport it does not own alone", which is the mock case).

Comment thread pkg/cmd/config/get/get.go
constants.ConfigShowOctopus,
constants.ConfigEditor,
// constants.ConfigProxyUrl,
constants.ConfigProxyUrl,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

octopus config get ProxyUrl (now offered in this interactive picker) prints the value raw via configFile.GetString(key) — including an embedded user:password. That contradicts the redaction added to config list and the PR's stated invariant that the password is never echoed. It matches the existing (also unredacted) config get ApiKey precedent, but since this PR adds the redaction machinery, consider running the value through apiclient.RedactProxyUrl in getRun when the key is ProxyUrl.

@NickJosevski NickJosevski Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actioned in 0ac4906. getRun now runs the value through apiclient.RedactProxyUrl when the key is ProxyUrl (matched with strings.EqualFold, since the key arrives in whatever case the user typed).

Verified with a binary built from 884abe2, config file holding "proxyurl": "http://octo:[email protected]:3128":

$ octopus config get ProxyUrl --no-prompt
http://octo:[email protected]:3128

Deliberately not extended to ApiKey/AccessToken: config get ApiKey printing the key raw is existing behaviour that something may well be scripted against, and changing it is a separate decision from "this PR should not add a new way to leak a secret".

Comment thread pkg/apiclient/proxy.go
// applyCredentials adds the configured proxy credentials, unless the proxy url
// already carries its own.
func (s ProxySettings) applyCredentials(proxyUrl *url.URL) *url.URL {
if s.Username == "" || proxyUrl.User != nil {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent failure mode: if OCTOPUS_PROXY_PASSWORD is set but OCTOPUS_PROXY_USERNAME is empty (unset, or typo'd var name), the credentials are dropped with no diagnostic — the request goes to the proxy unauthenticated and the user gets a bare 407 with no hint that the CLI ignored their password. Consider warning (or erroring) when Password != "" && Username == "" in ProxySettingsFromConfig/ProxyFunc.

@NickJosevski NickJosevski Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actioned in a5ee140, as an error rather than a warning.

Placed in the returned ProxyFunc closure rather than in ProxySettingsFromConfig, so it only fires when it actually matters — a proxy is in play for this request and the proxy url carries no credentials of its own:

if s.Password != "" && s.Username == "" && proxyUrl.User == nil {
    return nil, fmt.Errorf("%s is set but %s is empty, so the proxy credentials cannot be used", ...)
}

A stray OCTOPUS_PROXY_PASSWORD left in someone's shell profile therefore does not break a direct connection or a NO_PROXY-matched host, and it does not break the case where the password came from the url itself.

Error over warning because there is no good reading of it: the user asked for authenticated proxying, and the alternative is a 407 whose cause is invisible. It names both variables so a typo'd OCTOPUS_PROXY_USERNAME is self-diagnosing.

Tests: TestProxySettings_ProxyFuncRejectsAPasswordWithNoUsername and TestProxySettings_ProxyFuncIgnoresAPasswordWithNoProxy. Verified end to end with a binary built from 884abe2:

$ OCTOPUS_PROXY=http://proxy.invalid:3128 OCTOPUS_PROXY_PASSWORD=s3cret octopus space list --no-prompt
cannot get endpoint https://octopus.example.com/api/ from server. failure from http client
Get "https://octopus.example.com/api/": OCTOPUS_PROXY_PASSWORD is set but OCTOPUS_PROXY_USERNAME is empty, so the proxy credentials cannot be used

so it does survive the SDK's error wrapping and reaches the terminal.

Comment thread pkg/cmd/login/login.go Outdated
if err != nil {
return nil, err
}
spinnerRoundTripper.Next = transport

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two smaller notes on this branch: (1) as long as client_factory.go:123 hardcodes insecureSkipVerify=true, spinnerRoundTripper.Next is already an insecure proxy-aware transport, so this rebuild is a functional no-op that only resets the connection pool — it becomes meaningful once the factory plumbs the real setting (fine to keep for that reason, but worth a comment saying so). (2) It mutates the factory's shared client in place, so after login --ignore-ssl-errors every subsequent request in the process skips TLS verification, not just the login probe — intended for a one-shot CLI, but a trap for any longer-lived embedding.

@NickJosevski NickJosevski Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both notes were correct. (1) is now obsolete and (2) is documented rather than changed.

(1) — resolved by 884abe2. The premise was the hardcoded insecureSkipVerify=true in NewClientFactoryFromConfig, which made this rebuild a pool reset and nothing more. That hardcode is gone: the factory reads the real IgnoreSslErrors setting, so with the setting off and --ignore-ssl-errors passed, spinnerRoundTripper.Next genuinely goes from a verifying transport to a non-verifying one. The comment you asked for was added (aaaf8a6) and has since been removed as stale, since it now describes the opposite of what happens.

Also in 884abe2: ConfigureHttpClient ORs the IgnoreSslErrors config key into its parameter, so the standing opt-out applies to login too. Without that, login would have been the one command that still verified when the user had opted out globally — a confusing failure, because login is exactly the command you run first. Covered by TestConfigureHttpClient/"honours the IgnoreSslErrors config key without the flag" and .../"verifies the server certificate by default".

(2) — left as is, with the comment. The in-place mutation is deliberate and the code now says so:

// Note that this mutates the factory's shared client rather than cloning it, so
// --ignore-ssl-errors outlives the login probe and applies to every later request
// in the process: fine for a one-shot CLI, a trap for any longer-lived embedding.

Cloning would be the defensive fix, but it is not free: testLogin builds a ClientFactory from this same client and the spinner round-tripper is shared state by design, so a clone changes what the spinner sees and what TestConfigureHttpClient can assert about identity. It also buys nothing today — main() exits after one command. My read is that this stays as it is until something actually embeds the CLI, at which point the whole factory-returns-a-shared-client design needs the look, not just this line. Happy to be wrong if you know of an embedding: is there one?

Comment thread pkg/apiclient/proxy.go

// parseProxyUrl mirrors how net/http parses a proxy address: a bare "host:port"
// is treated as http.
func parseProxyUrl(rawUrl string) (*url.URL, error) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseProxyUrl re-implements the private parseProxy in golang.org/x/net/http/httpproxy (same err != nil || Scheme == "" || Host == "" + "http://"+addr fallback), but the parsed result is then discarded and the raw string is handed to httpproxy to parse again with its own copy of the rules. They match today; if either side changes (e.g. httpproxy tightens scheme handling), validation here and actual resolution there can drift apart — a url this function accepts could be silently ignored by httpproxy, which is exactly the failure the comment above says this validation exists to prevent. Consider assigning the parsed (normalized) url into config.HTTPProxy/HTTPSProxy via .String() so one parse is authoritative.

@NickJosevski NickJosevski Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actioned in 1b92f89, taking the suggested shape: the parsed url is now what httpproxy gets.

parsed, err := parseProxyUrl(s.Url)
if err != nil {
    return nil, err
}
config.HTTPProxy = parsed.String()
config.HTTPSProxy = parsed.String()

So the double parse is still there, but it is no longer two independent decisions about the same string: httpproxy parses a url that has already been normalized (scheme filled in), and the drift you describe — we accept host:port, httpproxy rejects it and silently connects direct — can't happen, because httpproxy never sees the un-schemed form.

On the dependency: golang.org/x/net was already in go.mod as an indirect dependency at v0.57.0, so using httpproxy costs nothing but promoting it to direct, which is the one-line go.mod change in this PR. No new module.

Residual I did not fix: parseProxy is still private, so the fallback rule (err != nil || Scheme == "" || Host == "" → prepend http://) is still duplicated as a rule, just no longer applied twice to the same input. Removing the duplication entirely would mean either vendoring the function or dropping httpproxy and reimplementing NO_PROXY matching, which is the part of that package genuinely worth having.

NickJosevski and others added 11 commits September 15, 2026 17:02
Standard HTTP_PROXY/HTTPS_PROXY/NO_PROXY already worked for API traffic
because the transport chain ends at http.DefaultTransport, but `octopus
login --ignore-ssl-errors` built a bare http.Transport that dropped proxy
support (and panicked when the client already had one).

Adds an OCTOPUS_PROXY environment variable and matching ProxyUrl config
key, which override HTTP_PROXY/HTTPS_PROXY for both schemes while still
honouring NO_PROXY. Credentials may be embedded in the url or supplied
via OCTOPUS_PROXY_USERNAME/OCTOPUS_PROXY_PASSWORD, which are read from
the environment only so a password is never written to the config file,
and are redacted in `config list`. socks5 comes free from net/http.

Refs #49

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
An invalid OCTOPUS_PROXY/ProxyUrl was reported by interpolating the raw
string into the error, and by wrapping url.Parse's *url.Error, which
repeats the whole url again. Both paths printed an embedded password to
the terminal and to CI logs. Redact the userinfo and unwrap the
*url.Error before reporting.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
parseProxyUrl validated the configured url and then threw the result
away, leaving httpproxy to parse the raw string again with its own copy
of the same rules. Pass the normalized url through instead, so the two
cannot drift into accepting here and ignoring there.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
OCTOPUS_PROXY_PASSWORD with no OCTOPUS_PROXY_USERNAME (unset, or a typo'd
variable name) dropped the credentials silently and the user got a bare
407 from the proxy with no hint that the CLI had ignored them. Fail with
a clear message instead, and only when a proxy is actually resolved and
carries no credentials of its own, so a stray variable cannot break a
direct connection.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The old code applied the ssl override to any non-nil client, building a
transport when the client had none. The rewrite only handled
*SpinnerRoundTripper, so a factory returning a plain &http.Client{} got
--ignore-ssl-errors silently ignored and no proxy. Build the proxy-aware
transport for that case too, and note in the comments that the spinner
branch is a no-op while the factory hardcodes insecureSkipVerify, and
that it mutates the factory's shared client.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
config get printed the stored ProxyUrl raw, including any embedded
user:password, which contradicted the redaction 'config list' applies to
the same key. ProxyUrl is new in this change, so nothing depends on the
raw value being readable back; the file itself is still there for anyone
who needs it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Both login paths always write AccessToken, and ShowOctopus is settable,
but neither had a case in the output switch, so any config file
containing them fell through to "the key '%s' is not a supported config
option" and printed nothing. AccessToken is masked above already, so it
lists as *** like ApiKey.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The reviewer is right that the CLI never verifies the Octopus
certificate, but that predates this change and flipping it here would
break self-signed installs with no way to opt out. Say so at the call
site so the next reader does not have to rediscover it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The previous wording implied no proxy password can reach the config
file, but one embedded in ProxyUrl does. Say which of the two is stored,
that display is masked, and that the password variable needs the
username variable.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
The CLI set InsecureSkipVerify on the shared http.DefaultTransport
unconditionally, so it never verified the Octopus Server's TLS
certificate: --ignore-ssl-errors was effectively always on and anything
on the network path could read the API key sent with every request.

Verification is now on, and turning it off is an explicit choice: the
new IgnoreSslErrors config key, its OCTOPUS_IGNORE_SSL_ERRORS
environment variable, or 'octopus login --ignore-ssl-errors' for a
single login. login honours the config key as well as its own flag so
it is not the odd command out.

This is a deliberate behaviour change. Anyone relying on the old
behaviour, typically a self-signed certificate, now gets a certificate
error until they add the CA to the trust store or opt out.

'config set' rejects a non-boolean IgnoreSslErrors value rather than
storing something viper would read back as false, and the key is listed
by 'config list -f json' and offered by the 'config get'/'config set'
pickers.

Covered by TestNewClientFactoryFromConfig_TlsVerification,
TestConfigureHttpClient's two new subtests, and the two new
config.Setup tests.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
#726 shipped in v2.25.1 while this branch was open, and the two disagreed:
this branch left an unrecognised transport alone, #726 refuses with an error
rather than quietly leaving verification on after the caller asked for the
opposite. #726's behaviour wins.

ConfigureHttpClient now switches on the transport the way #726's
skipTlsVerification did - spinner wrapper, plain transport, or an error - but
builds the replacement through NewHttpTransport, so the ssl override stays
proxy-aware. skipTlsVerification and its internal test go with it; the cases
they covered are now in TestConfigureHttpClient.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant